<?php
/* 搜索引擎
*
*
*/

namespace hpnWse\nSe {

require_once(__DIR__ . '/(0)Base.php');
require_once(\hpnWse\fGetWseDiry() . 'hpnWse/SqlUtil.php');

use \hpnWse\stNumUtil;
use \hpnWse\stStrUtil;
use \hpnWse\stObjUtil;
use \hpnWse\stAryUtil;
use \hpnWse\stDateUtil;
use \hpnWse\stSqlUtil;
use \hpnWse\nSe\stBase;


/// 后缀索引器
class tIdxr_Sfx extends atIdxr
{
	public $c_Cfg = null; // Object，配置
	public $c_StnDic_Tknz = null; // 句子词典 - 词元化
	public $c_StnDic_Mark = null; // 句子词典 - 高亮
	public $c_CwsKwds = null; // String[]，中文分词后的关键词
	public $c_QryKwds = null; // String[]，查询用的关键词，数量可能少于c_CwsKwds
	public $c_MarkCnt = 0; // 见cMarkKwds

	private $e_SttTime = 0; // 这两个变量用于记录操作用时
	public $c_UseTime = 0;

	/// 文档字段映射，键是文档类型，值是一个Object，例如：
	/// $l_Map = array();
	/// stObjUtil::cAddPptys($l_Map,
	///	//	Top 		   列			     嵌套列
	/// 	200, 	array('c_Keyword'                ),
	/// 	180,	array('c_Title'                  ),
	///		40,		array('html c_Content',          ),
	///		20,		array('c_Members',      'c_Name' ),
	/// );
	/// 对于例中的嵌套列，若c_Members是数组，则依次遍历每个元素分别处理
	/// “html c_Content”表示列“c_Content”是富文本
	public $c_DocTopMap = array();

	/// 构造
	/// a_Cfg:
	/// {
	/// c_MaxSfxLen: Number，最大后缀长度，默认8
	/// c_MaxSfxLen_CC: Number，汉字最大后缀长度，默认4
	///		【注意：这个优化还是很重要的，nSe测试显示 906 VS 1077，而许多熟语和成语都是四字】
	/// c_MaxDocLen: Number，最大文档长度，默认65535
	/// c_MaxTermCnt: Number，用语在指定文档里的最大计数，默认255
	/// c_MaxSrchKwdAmt: Number，最多搜索关键词数，默认2
	/// c_MaxSrchRstAmt: Number，最多搜索结果数，默认10000
	/// c_RcdTop: Boolean，是否记录顶级字段？若记录则检索时方便排名和生成摘要
	/// c_CaseSens: Boolean，大小写敏感？默认false
	/// c_Cws: atCws，中文分词，必须有效
	/// c_Pdo: PDO
	/// c_Tab_Term: String，用语表[c_Id, c_Term]
	/// c_Tab_Doc：String，文档表[c_Id, c_Type, c_Key, c_State, c_Length, c_Time]
	///		其中c_State列若为-1表示删除，为0表示隐藏，为1表示可见
	/// c_Tab_DocCud: String，文档CUD表[c_Type, c_Key, c_State]
	///		当对文档CUD时录入这里，服务器利用该表周期性更新索引
	/// c_Tab_TermDocCnt：指定用语在指定文档里的计数表[c_TermId, c_DocId, c_Count, c_Top]，
	///		其中c_Top列仅当c_RcdTop为true时使用，记录包含用语的顶级字段
	/// c_Sp_SrchN: String，存储过程，执行搜索N个关键词，N=1,2,3,...
	/// }
	public function __construct($a_Cfg)
	{
		parent::__construct();

		$this->c_Cfg = array(
			'c_MaxSfxLen' => stObjUtil::cFchIntPpty($a_Cfg, 'c_MaxSfxLen', 8),
			'c_MaxSfxLen_CC' => stObjUtil::cFchIntPpty($a_Cfg, 'c_MaxSfxLen_CC', 4),
			'c_MaxDocLen' => stObjUtil::cFchIntPpty($a_Cfg, 'c_MaxDocLen', 65535),
			'c_MaxTermCnt' => stObjUtil::cFchIntPpty($a_Cfg, 'c_MaxTermCnt', 255),
			'c_MaxSrchKwdAmt' => stObjUtil::cFchIntPpty($a_Cfg, 'c_MaxSrchKwdAmt', 2),
			'c_MaxSrchRstAmt' => stObjUtil::cFchIntPpty($a_Cfg, 'c_MaxSrchRstAmt', 10000),
			'c_RcdTop' => stObjUtil::cFchBoolPpty($a_Cfg, 'c_RcdTop'),
			'c_CaseSens' => stObjUtil::cFchBoolPpty($a_Cfg, 'c_CaseSens'),
			'c_Cws' => stObjUtil::cFchPpty($a_Cfg, 'c_Cws'),
			'c_Pdo' => $a_Cfg['c_Pdo'],
			'c_Tab_Term' => $a_Cfg['c_Tab_Term'],
			'c_Tab_Doc' => $a_Cfg['c_Tab_Doc'],
			'c_Tab_DocCud' => $a_Cfg['c_Tab_DocCud'],
			'c_Tab_TermDocCnt' => $a_Cfg['c_Tab_TermDocCnt'],
			'c_Sp_SrchN' => $a_Cfg['c_Sp_SrchN'],
		);

		$this->c_StnDic_Tknz = tDic::scCopy(stBase::cAcsStnDic());
		$this->c_StnDic_Tknz
			->cAdd('、')->cAdd('，')->cAdd('；')->cAdd('：')
			->cAdd('‘')->cAdd('’')
			->cAdd('“')->cAdd('”')
			->cAdd('（')->cAdd('）')
			->cAdd('【')->cAdd('】')
			->cAdd('《')->cAdd('》')
			->cAdd('—')->cAdd('——')
			->cAdd('～')
		;

		$this->c_StnDic_Mark = tDic::scCopy(stBase::cAcsStnDic());
		$this->c_StnDic_Mark
			->cAdd('，')->cAdd('；')->cAdd('：')
		;
	}

	/// 生成全部后缀
	/// a_Rst: String[]，结果
	/// a_Chas: String[]，字符数组
	/// a_ChasLen：Number，a_Chas若为String则strlen()，否则count()
	/// a_MaxSfxLen: Number，最大后缀长度
	/// a_MaxSfxLen_CC: 同上，但仅用于左起连续汉字，默认null表示同上面
	public static function scGnrtAllSfx(&$a_Rst, &$a_Chas, $a_ChasLen, $a_MaxSfxLen, $a_MaxSfxLen_CC = null)
	{
		$l_Ccs = (($a_MaxSfxLen_CC > 0) && ($a_MaxSfxLen_CC < $a_MaxSfxLen)) ? array() : null; // 用来记录是不是汉字

		for ($i=$a_ChasLen-1; $i>=0; --$i) // 倒序
		{
			$l_SfxLen = min($a_ChasLen - $i, $a_MaxSfxLen);
			if (null !== $l_Ccs) // 需判断是否为全汉字，并相应缩短后缀长度
			{
				array_unshift($l_Ccs, stBase::cIsCha_CC(stBase::cCodeOfCha($a_Chas[$i])));
				
				if ($l_SfxLen > $a_MaxSfxLen_CC)
				{
					$l_CcsLen = min($a_ChasLen - $i, $a_MaxSfxLen_CC);
					for ($j = 0; $j<$l_CcsLen; ++$j)
					{
						if (!$l_Ccs[$j])
						{ break; }
					}
					if ($j >= $l_CcsLen)
					{
						$l_SfxLen = $a_MaxSfxLen_CC;
					}
				}
			}

			// 跳过空白开头的，剪掉结尾的空白
			if (stStrUtil::cIsWhtSpc($a_Chas[$i]))
			{ continue; }

			$a_Rst[] = trim(implode('', array_slice($a_Chas, $i, $l_SfxLen)));
		}
	}

	/// 创建文档
	/// a_State：Number，若＞0则映射为1，否则映射为0
	/// 返回：Number，文档ID∈c_Tab_Doc
	public function cCrtDoc($a_Type, $a_Key, $a_State = 1)
	{
		return $this->vdDb_CrtDoc($a_Type, $a_Key, $a_State);
	}

	/// 查询文档ID
	public function cQryDocId($a_Type, $a_Key)
	{
		$l_Cfg = &$this->c_Cfg;
		$l_Pdo = $l_Cfg['c_Pdo'];
		$l_Rst = stSqlUtil::cReadRow_Cmpr2($l_Pdo, $l_Cfg['c_Tab_Doc'], 'c_Id',
			'c_Type', '=', stSqlUtil::cEscVal($a_Type, true), 'c_Key', '=', $a_Key, true);
		return $l_Rst ? $l_Rst['c_Id'] : 0;
	}

	/// 搜索文档，内部更新c_CwsKwds和c_UseTime
	/// a_Kwds: String，关键词，可以是自然语言
	/// a_Type: String，文档类型，默认null或空串表示全部
	/// 返回：{ c_Type: String 类型, c_Key: Number 键, c_Top: Number 顶级字段, c_Relevance: Number 相关度 }[]
	public function cSrchDocs($a_Kwds, $a_Type = null)
	{
		if ('' === $a_Kwds) { return array(); }
		if ('' === $a_Type) { $a_Type = null; }

		$this->e_SttTime = microtime(true);
		$l_Rst = $this->eSrchDocs($a_Kwds, $a_Type);
		$this->eCalcUseTime();
		return $l_Rst;
	}

	private function eSrchDocs($a_Kwds, $a_Type)
	{
		$l_Cfg = &$this->c_Cfg;
		$l_Pdo = $l_Cfg['c_Pdo'];

		// 1. 分词并去重，如果过多则根据长度排序，选出最长的几个
		// 注意，如果关键词长度不超过最小的最大后缀长度，当做一个词处理
		$this->c_CwsKwds = array();
		$l_CwsKwds = &$this->c_CwsKwds;

		if (mb_strlen($a_Kwds) <= min($l_Cfg['c_MaxSfxLen'], $l_Cfg['c_MaxSfxLen_CC']))
		{
			$l_CwsKwds[] = $a_Kwds;
		}
		else
		{
			$l_Cfg['c_Cws']->cRun($l_CwsKwds, $a_Kwds);	
			for ($i=count($l_CwsKwds)-1; $i>0; --$i)
			{
				for ($j=$i-1; $j>=0; --$j)
				{
					if ($l_CwsKwds[$j] == $l_CwsKwds[$i])
					{
						array_splice($l_CwsKwds, $i, 1);
						break;
					}
				}
			}
		}
		\hpnWse\stHttpSvc::$c_Dbg['c_CwsKwds'] = $l_CwsKwds;

		$this->c_QryKwds = $l_CwsKwds; // 拷贝一份
		$l_QryKwds = &$this->c_QryKwds;
		if (count($l_QryKwds) > $l_Cfg['c_MaxSrchKwdAmt'])
		{
			stAryUtil::cSort($l_QryKwds, 
				function ($a_1, $a_2)
				{
					return (mb_strlen($a_1) >= mb_strlen($a_2)) ? -1 : +1;
				});
			$l_QryKwds = stAryUtil::cSub($l_QryKwds, 0, $l_Cfg['c_MaxSrchKwdAmt']);
		}
		$l_QKA = count($l_QryKwds);

		// 2. 调用对应关键次数的存储过程
		$l_SpName = $l_Cfg['c_Sp_SrchN']; if ($l_QKA > 1) { $l_SpName .= $l_QKA; }
		$l_SpAgms = array();
		$l_SpAgms[] = $a_Type;
		stAryUtil::cCcatTo($l_SpAgms, $l_QryKwds);
		$l_SpAgms[] = $l_Cfg['c_MaxSrchRstAmt'];
		$l_Rst = stSqlUtil::cCallFchAll($l_Pdo, $l_SpName, $l_SpAgms);

		// 3. 转型c_Top和c_Relevance
		$l_RstLen = count($l_Rst);
		for ($i=0; $i<$l_RstLen; ++$i)
		{
			$l_Rsti = &$l_Rst[$i];
			$l_Rsti['c_Key'] = intval($l_Rsti['c_Key']);
			$l_Rsti['c_Top'] = intval($l_Rsti['c_Top']);
			$l_Rsti['c_Relevance'] = floatval($l_Rsti['c_Relevance']);
		}
		return $l_Rst;
	}

	/// 根据文档类型分组搜索结果
	/// a_GrpRst: Array，分组结果，键是c_Type，值是{ c_Keys: int[], c_Tops: int[] }
	/// a_SrchRst: cSrchDocs()的返回值，不会修改
	public function cGrpSrchRstByType(&$a_GrpRst, &$a_SrchRst)
	{
		$l_Len = count($a_SrchRst);
		for ($i=0; $i<$l_Len; ++$i)
		{
			$l_SR = &$a_SrchRst[$i];
			$l_Type = $l_SR['c_Type'];
			if (!isset($a_GrpRst[$l_Type]))
			{ $a_GrpRst[$l_Type] = array('c_Keys' => array(), 'c_Tops' => array()); }

			$a_GrpRst[$l_Type]['c_Keys'][] = $l_SR['c_Key']; 
			$a_GrpRst[$l_Type]['c_Tops'][] = $l_SR['c_Top'];
		}
	}

	/// 标记关键词，假定c_CwsKwds可用
	/// a_Text: String，本函数假定已Html解码，与c_CwsKwds匹配后再Html编码返回
	/// a_SmryLen: Number，摘要长度，0表示不限制
	/// 返回：String，带有<$a_Tag>...</$a_Tag>，同时会设置$this->c_MarkCnt为添加的标记数
	public function cMarkKwds($a_Tag, $a_Text, $a_SmryLen = 0)
	{
		$l_Cfg = &$this->c_Cfg;
		$this->c_MarkCnt = 0;

		$l_Kwds = &$this->c_CwsKwds;	// 例如：["达令"]
		$l_KwdsLen = $l_Kwds ? count($l_Kwds) : 0;
		if (0 == $l_KwdsLen)
		{ return $a_Text; }

		// 建立词典
		$l_Dic = new tDic();
		foreach ($l_Kwds as $l_Idx => $l_Kwd)
		{ $l_Dic->cAdd($l_Kwd); }

		$l_Rst = ($a_SmryLen > 0) ? array() : '';
		$l_Btag = '<' . $a_Tag . '>';
		$l_Etag = '</' . $a_Tag . '>';

		// 拆分成段落
		$l_StnDic = $this->c_StnDic_Mark;
		$l_MarkStns = array();

		$l_Paras = array();
		stBase::cSplTextToParas($l_Paras, $a_Text);
		$l_ParasLen = count($l_Paras);
		for ($p=0; $p<$l_ParasLen; ++$p)
		{
			// 拆分成句子，
			$l_Stns = array();
			stBase::cSplParaToStns($l_Stns, $l_Paras[$p], count($l_Paras[$p]), 
				function ($a_Chas, $a_ChasLen, $a_Bgn) use($l_StnDic)
				{
					return $l_StnDic->cQry($a_Chas, $a_ChasLen, $a_Bgn);
				}, true); // 包含标点

			// 对每个句子，使用词典找出匹配的词，并统计匹配次数
			$l_StnsLen = count($l_Stns);
			for ($s=0; $s<$l_StnsLen; ++$s)
			{
				$l_Stn = &$l_Stns[$s];
				$l_StnLen = count($l_Stn);
				$l_CI = 0;
				$l_Cnt = 0;
				$l_MarkStn = '';
				while ($l_CI < $l_StnLen)
				{
					$l_CE = $l_Dic->cQry($l_Stn, $l_StnLen, $l_CI);
					if ($l_CE < 0)
					{
						$l_MarkStn .= stStrUtil::cEcdHtmlScha($l_Stn[$l_CI]);
						++$l_CI;
						continue;
					}

					// 匹配
					++$l_Cnt;
					$l_MarkStn .= $l_Btag;
					$l_MarkStn .= stStrUtil::cEcdHtmlScha(implode('', stAryUtil::cSub($l_Stn, $l_CI, $l_CE + 1)));
					$l_MarkStn .= $l_Etag;
					$l_CI = $l_CE + 1;
				}

				// 有至少一次匹配，录入，最后根据匹配次数排序
				if ($l_Cnt > 0)
				{
					$l_MarkStns[] = array($l_MarkStn, $l_StnLen, $l_Cnt);
				}
			}
		}

		$l_MarkStnsLen = count($l_MarkStns);
		if (0 == $l_MarkStnsLen) // 如果为0，原样返回
		{ return $a_Text; }

		// 排序，连接各句
		stAryUtil::cSort($l_MarkStns, function ($a_1, $a_2) { return $a_1[2] - $a_2[2]; });
		
		$l_Rst = '';
		$l_RstLen = 0;
		for ($i=0; $i<$l_MarkStnsLen; ++$i)
		{
			$l_MarkStn = &$l_MarkStns[$i];
			if ($l_Rst)
			{
				$l_Rst .= '… ';
				$l_RstLen += 2;
			}
			$l_Rst .= $l_MarkStn[0];
			$l_RstLen += $l_MarkStn[1];

			if (($a_SmryLen > 0) && ($l_RstLen >= $a_SmryLen))
			{
				break;
			}
		}

		// // 最后，去除相邻的标记 【不必了，英文单词间必须有空格】
		// $i_Rgx_Mark = '|' . $l_Etag . '\\s*' . $l_Btag . '|'; // |</mark>\s*<mark>|
		// $l_Rst = preg_replace($i_Rgx_Mark, '', $l_Rst);
		return $l_Rst;
	}

	// /// 映射组记录（同一类型）
	// /// a_Grp：Object，cGrpSrchRstByType()的某一分组，将追加到c_Rcds字段，键是a_Rcds每项主键
	// /// a_Rcds: Object[]，取到的记录
	// /// a_RcdKey：String，a_Rcds每项主键
	// /// a_Rules: Object，映射规则，例如：
	// ///		{
	// ///		'c_Figure' => 'c_Logo'：表示将a_Rcds每项的“c_Logo”映射到a_Grp['c_Rcds'][该项主键]['c_Figure']
	// ///		'c_Summary' => array'c_Content'
	// ///		}
	// public function cMapRcds(&$a_Grp, &$a_Rcds, $a_RcdKey, $a_Rules)
	// {
	// 	if (!isset($a_Grp['c_Rcds']))
	// 	{ $a_Grp['c_Rcds'] = array(); }

	// 	$l_GrpRcds = &$a_Grp['c_Rcds'];
	// 	$l_Len = count($a_Rcds);
	// 	for ($i=0; $i<$l_Len; ++$i)
	// 	{
	// 		$l_Rcd = &$a_Rcds[$i];
	// 		$l_GrpRcds[$l_Rcd[$a_RcdKey]] = array();
	// 	}
	// }

	/// 映射文档顶级字段
	/// a_Type: String，文档类型，必须有效，指向c_DocTopMap某个属性
	/// a_Doc: Object，文档数据，不会修改
	/// a_Top: Number，若为null表示返回cCrtForDoc()需要的a_TextCfgs，
	/// 	否则返回对应文档字段，若该字段是数组，则返回String[]，保持一一对应
	/// 返回：见a_Top
	public function cMapDocTop($a_Type, &$a_Doc, $a_Top = null)
	{
		if (!isset($this->c_DocTopMap[$a_Type]))
		{
			throw new \Exception('tIdxr_Sfx::cMapDocTop: a_Type无效！', -1);
		}

		$l_Map = &$this->c_DocTopMap[$a_Type];

		// 创建a_TextCfgs
		if (null === $a_Top)
		{
			$l_TextCfgs = array();
			foreach ($l_Map as $l_Top => $l_Ary) 
			{
				if (count($l_Ary) > 1) // 嵌套
				{
					$l_SubDoc = &$a_Doc[$l_Ary[0]];
					if (isset($l_SubDoc[$l_Ary[1]])) // 纯对象
					{
						$l_TextCfgs[] = array(
							self::seMapDocTop_Fch($l_SubDoc, $l_Ary[1]),
							intval($l_Top)
						);
					}
					else // 数组
					{
						$l_SubDocLen = count($l_SubDoc);
						for ($j=0; $j<$l_SubDocLen; ++$j)
						{
							$l_TextCfgs[] = array(
								self::seMapDocTop_Fch($l_SubDoc[$j], $l_Ary[1]),
								intval($l_Top)
							);
						}
					}
				}
				else
				{
					$l_TextCfgs[] = array(
						self::seMapDocTop_Fch($a_Doc, $l_Ary[0]),
						intval($l_Top)
					);
				}
			}
			return $l_TextCfgs;
		}

		// 返回对应文档字段
		if (!isset($l_Map[$a_Top]))
		{ return ''; }

		$l_Ary = &$l_Map[$a_Top];
		if (count($l_Ary) > 1) // 嵌套
		{
			$l_SubDoc = &$a_Doc[$l_Ary[0]];
			if (isset($l_SubDoc[$l_Ary[1]])) // 纯对象
			{
				return self::seMapDocTop_Fch($l_SubDoc, $l_Ary[1]);
			}
			else // 数组
			{
				$l_Rst = array();
				$l_SubDocLen = count($l_SubDoc);
				for ($i=0; $i<$l_SubDocLen; ++$i)
				{
					$l_Rst[] = self::seMapDocTop_Fch($l_SubDoc[$i], $l_Ary[1]);
				}
				return $l_Rst;// ? $l_Rst : ''; //【会出现这种奇怪情况吗？由调用者处理】
			}
		}
		else
		{
			return self::seMapDocTop_Fch($a_Doc, $l_Ary[0]);
		}
	}

	private static function seMapDocTop_Fch(&$a_Doc, $a_Pn)
	{
		$l_IsHtml = stStrUtil::cIsPfx($a_Pn, 'html ');
		$l_Rst = $l_IsHtml ? strip_tags($a_Doc[substr($a_Pn, 5)]) : $a_Doc[$a_Pn];

		// Html解码
		return stStrUtil::cDcdHtmlScha($l_Rst);
	//	return $l_Rst;
	}

	/// 为文档创建索引，假定不存在旧索引，内部更新c_UseTime
	/// a_DocId: Number，文档ID∈c_Tab_Doc
	/// a_TextCfgs: Object[]，每个元素都是utf8编码的文本配置，结构如下：
	///		[
	///		0: String, 文本，必须有效
	///		1: Number，顶级字段，仅当配置项c_RcdTop为true，默认0
	///		]
	public function cCrtForDoc($a_DocId, &$a_TextCfgs)
	{
		$this->e_SttTime = microtime(true);
		$this->eCuForDoc(1, $a_DocId, $a_TextCfgs);
		$this->eCalcUseTime();
		return $this;
	}

	/// 为文档更新索引，将删除旧索引，内部更新c_UseTime
	public function cUpdForDoc($a_DocId, &$a_TextCfgs)
	{
		$this->e_SttTime = microtime(true);
		$this->eCuForDoc(2, $a_DocId, $a_TextCfgs);
		$this->eCalcUseTime();
		return $this;
	}

	private function eCuForDoc($a_Which, $a_DocId, &$a_TextCfgs)
	{
		// 生成词元映射
		$l_Cfg = &$this->c_Cfg;
		$l_RcdTop = $l_Cfg['c_RcdTop'];

		$l_TknMap = array();
		$l_TknCnt = 0;
		$l_Len = count($a_TextCfgs);
		for ($i=0; $i<$l_Len; ++$i)
		{
			$l_TC = &$a_TextCfgs[$i];
			$this->cTknzText($l_TknMap, $l_TknCnt, $l_TC[0], ($l_RcdTop ? $l_TC[1] : null));
		}

		// //【本地测试】
		// if (defined('mi_LocHostTest'))
		// {
		// 	echo ($a_DocId) . ', L = ' . $l_TknCnt . '<br>'; 
		// 	print_r(stObjUtil::cEcdJson($l_TknMap));
		// }

		// 先删除旧的（没有也没关系）
		if (2 == $a_Which)
		{
			$this->vdDb_RmvTknMapForDoc($a_DocId);
		}

		$this->vdDb_StoTknMapForDoc($a_DocId, $l_TknCnt, $l_TknMap);
	}

	/// 为文档删除索引
	public function cDltForDoc($a_DocId)
	{
		$this->vdDb_RmvTknMapForDoc($a_DocId);
		return $this;
	}

	// /// 当CUD文档时
	// /// a_Op: Number，-1=删除，1=创建，2=更新
	// public function cOnCudDoc($a_Type, $a_Key, $a_Op)
	// {
	// 	return $this;
	// }

	// /// 当显示隐藏文档时
	// /// a_Op: Number，0=隐藏，1=显示
	// public function cOnShDoc($a_Type, $a_Key, $a_Op)
	// {
	// 	return $this;
	// }

	/// 将文本划分词元
	/// a_TknMap：Object，键是词元，值是个数，若启用顶级字段，则值是数组，[0]是个数，[1]记录顶级字段
	/// a_TknCnt：Number，词元计数，初始化为0，只是一个估算值，对于简体汉字＝ceil(短句字符数÷2)
	/// a_Text：String, 文本，对应文档的一个字段
	/// a_Top: Number, 顶级字段，默认null表示不使用，越大优先级越高
	public function cTknzText(&$a_TknMap, &$a_TknCnt, 
							&$a_Text, $a_Top = null)
	{
		if (!\hpnWse\fBool($a_Text))
		{ return; }

		// 拆行，分别处理每行
		$l_Paras = array();
		stBase::cSplTextToParas($l_Paras, $a_Text);
		$l_ParasLen = count($l_Paras);
		for ($l=0; $l<$l_ParasLen; ++$l)
		{
			$this->cTknzPara($a_TknMap, $a_TknCnt, $l_Paras[$l], count($l_Paras[$l]), $a_Top);
		}
	}

	/// 将段落划分词元
	/// $a_Chas, $a_ChasLen：字符数组和长度，便于高效分析，【警告：头尾不能有空白】
	/// 其他参数详见：见scTknzText
	public function cTknzPara(&$a_TknMap, &$a_TknCnt, 
							&$a_Chas, $a_ChasLen, $a_Top = null)
	{
		if (0 == $a_ChasLen)
		{ return; }

		$l_This = $this;
	//	$l_Cfg = &$this->c_Cfg;
	//	$l_MSL = $l_Cfg['c_MaxSfxLen'];
	//	$l_MTC = $l_Cfg['c_MaxTermCnt'];
	//	$l_CS = $l_Cfg['c_CaseSens'];
	//	$l_Cws = $l_Cfg['c_Cws'];

		// 先将段落拆分成句子
		$l_StnDic = $this->c_StnDic_Tknz;
		$l_Stns = array();
		stBase::cSplParaToStns($l_Stns, $a_Chas, $a_ChasLen, 
			function ($a_Chas, $a_ChasLen, $a_Bgn) use($l_StnDic)
			{
				return $l_StnDic->cQry($a_Chas, $a_ChasLen, $a_Bgn);
			}, false); // 不要包含标点

		// 对每句话，为保证查全率，全部后缀都做索引
		$l_StnsLen = count($l_Stns);
		for ($i=0; $i<$l_StnsLen; ++$i)
		{
			$this->cTknzStn($a_TknMap, $a_TknCnt, $l_Stns[$i], count($l_Stns[$i]), $a_Top);
		}
	}

	/// 将句子划分词元
	/// $a_Chas, $a_ChasLen：字符数组和长度，便于高效分析，【警告：头尾不能有空白】
	/// 其他参数详见：见scTknzText
	public function cTknzStn(&$a_TknMap, &$a_TknCnt, 
							&$a_Chas, $a_ChasLen, $a_Top = null)
	{
		if (0 == $a_ChasLen)
		{ return; }

		$l_This = $this;
		$l_Cfg = &$this->c_Cfg;
		$l_MSL = $l_Cfg['c_MaxSfxLen'];
		$l_MSL_CC = $l_Cfg['c_MaxSfxLen_CC'];
		$l_MTC = $l_Cfg['c_MaxTermCnt'];
		$l_CS = $l_Cfg['c_CaseSens'];
	//	$l_Cws = $l_Cfg['c_Cws'];

		// 只有一个字符
		if (1 == $a_ChasLen)
		{
			if (stStrUtil::cIsWhtSpc($a_Chas[0])) // 跳过空白
			{ return; }

			$l_Sfx = $l_CS ? $a_Chas[0] : strtolower($a_Chas[0]);
			self::sdIncTermCnt($a_TknMap, $l_Sfx, $l_MTC, $a_Top);
			$a_TknCnt += 1;
			return;
		}

		// 全部后缀
		$l_Sfxs = array();
		self::scGnrtAllSfx($l_Sfxs, $a_Chas, $a_ChasLen, $l_MSL, $l_MSL_CC);
		$l_SfxsLen = count($l_Sfxs);
		for ($j=0; $j<$l_SfxsLen; ++$j)
		{
			$l_Sfx = $l_CS ? $l_Sfxs[$j] : strtolower($l_Sfxs[$j]);
			self::sdIncTermCnt($a_TknMap, $l_Sfx, $l_MTC, $a_Top);
		}

		// 必须估算用语数，拆分成短语
		$l_Phrs = array();
		stBase::cSplStnToPhrs($l_Phrs, $a_Chas, $a_ChasLen);
		$l_PhrsLen = count($l_Phrs);
		for ($p=0; $p<$l_PhrsLen; ++$p)
		{			
			// 汉字，估算词数 = 字数÷2
			if (('i_CC' === $l_Phrs[$p][0]))
			{
				$a_TknCnt += ceil(count($l_Phrs[$p][1]) / 2);
			}
			else // 其他都算作一个
			{
				$a_TknCnt += 1;
			}
		}
	}

	/// 递增用语计数
	/// a_TknMap：Object，键是词元，值是个数，若启用顶级字段，则值是数组，[0]是个数，[1]记录顶级字段
	/// a_Term: String，用语
	/// a_Max: Number，最大值
	/// a_Top: Number，顶级字段，默认null表示不使用，越大优先级越高
	public static function sdIncTermCnt(&$a_TknMap, $a_Term, $a_Max = PHP_INT_MAX, $a_Top = null)
	{
		if (!isset($a_TknMap[$a_Term]))
		{ $a_TknMap[$a_Term] = (null === $a_Top) ? 0 : array(0, 0); }
		
		if (null === $a_Top)
		{
			$a_TknMap[$a_Term] = min($a_TknMap[$a_Term] + 1, $a_Max);
		}
		else
		{
			$l_Tkn = &$a_TknMap[$a_Term];
			$l_Tkn[0] = min($l_Tkn[0] + 1, $a_Max);
			$l_Tkn[1] = max($l_Tkn[1], $a_Top);
		}
	}

	/// DB - 创建文档
	/// 返回：Number，文档ID∈c_Tab_Doc
	public function vdDb_CrtDoc($a_Type, $a_Key, $a_State = 1)
	{
		$l_Cfg = &$this->c_Cfg;
		$l_Pdo = $l_Cfg['c_Pdo'];
		return stSqlUtil::cCrtRow($l_Pdo, $l_Cfg['c_Tab_Doc'], 
			array(
				'c_Type' => $a_Type,
				'c_Key' => $a_Key,
				'c_Length' => 0,
				'c_State' => (intval($a_State) > 0) ? 1 : 0,
				'c_Time' => stDateUtil::cNowDatm()
			));
	}

	/// DB - 移除文档的词元映射
	public function vdDb_RmvTknMapForDoc($a_DocId)
	{
		$l_Cfg = &$this->c_Cfg;
		$l_Pdo = $l_Cfg['c_Pdo'];
		stSqlUtil::cDltRows_Cmpr($l_Pdo, $l_Cfg['c_Tab_TermDocCnt'],
			'c_DocId', '=', $a_DocId, true);
	}

	/// DB - 为文档储存词元映射
	public function vdDb_StoTknMapForDoc($a_DocId, $a_Len, &$a_TknMap)
	{
		$l_Cfg = &$this->c_Cfg;
		$l_Pdo = $l_Cfg['c_Pdo'];

		// 更新文档长度
		$l_Afcd = stSqlUtil::cUpdRows_Cmpr($l_Pdo, $l_Cfg['c_Tab_Doc'], 
			array('c_Length' => $a_Len, 'c_Time' => stDateUtil::cNowDatm()), 
			'c_Id', '=', $a_DocId, true);
		// if (1 !== $l_Afcd) //【可能没变】
		// {
		// 	throw new \Exception('更新文档的c_Length失败！');
		// }

		// 先将用语全部插入到用语表，若已存在则忽略即可
	//	stSqlUtil::$c_LogSql = true;
		$l_Terms = array_keys($a_TknMap);
		$l_TermsLen = count($l_Terms);
		sort($l_Terms, SORT_STRING); //【注意：应该排序！】

		$l_IstVals = ''; // 使用SIMD高效插入
		$l_Maps = array(); // 由于要修改l_Terms，转移映射
		for ($i=0; $i<$l_TermsLen; ++$i)
		{
			$l_Term = $l_Terms[$i];
			$l_Maps[] = $a_TknMap[$l_Term];

			if ($l_IstVals) { $l_IstVals .= ','; }
			$l_IstVals .= '(';
			$l_IstVals .= ($l_Terms[$i] = stSqlUtil::cEscVal($l_Term, true)); // 转义并带上引号
			$l_IstVals .= ')';
		}
		stSqlUtil::cCrtRows_Simd($l_Pdo, $l_Cfg['c_Tab_Term'], 'c_Term', $l_IstVals, 'IGNORE');

		// 然后从用语表按顺序取得c_TermId
		$l_SqlWhere = 'WHERE ';
		$l_SqlWhere .= stSqlUtil::cBldStmt_In('c_Term', $l_Terms);
		$l_SqlOdrBy = stSqlUtil::cBldStmt_OrderByFld('c_Term', $l_Terms);

		//【这个太低效】
	//	$l_TermIds = stSqlUtil::cReadRows($l_Pdo, $l_Cfg['c_Tab_Term'], 'c_Id', $l_SqlWhere, $l_SqlOdrBy);

		$l_Tab_Term = $l_Cfg['c_Tab_Term'];
$l_Sql = <<<SQL
SELECT c_Id 
FROM $l_Tab_Term
$l_SqlWhere
$l_SqlOdrBy
SQL;
		$l_PdoStmt = stSqlUtil::cQry($l_Pdo, $l_Sql);
		$l_TermIds = stSqlUtil::cFchAllId($l_PdoStmt);
		$l_TermIdsLen = count($l_TermIds);
		if ($l_TermsLen !== $l_TermIdsLen)
		{
			throw new \Exception("(l_TermsLen = $l_TermsLen) !== (l_TermIdsLen = $l_TermIdsLen)", -1);
		}

		// 再利用c_TermId插入到词频表
		$l_IstVals = ''; // 使用SIMD高效插入
		for ($i=0; $i<$l_TermsLen; ++$i)
		{
			$l_Map = &$l_Maps[$i];

			if ($l_IstVals) { $l_IstVals .= ','; }
			$l_IstVals .= '(';
			$l_IstVals .= strval($l_TermIds[$i]);
			$l_IstVals .= ',';
			$l_IstVals .= strval($a_DocId);
			$l_IstVals .= ',';
			$l_IstVals .= strval($l_Map[0]);
			$l_IstVals .= ',';
			$l_IstVals .= strval($l_Map[1]);
			$l_IstVals .= ')';
		}
		stSqlUtil::cCrtRows_Simd($l_Pdo, $l_Cfg['c_Tab_TermDocCnt'], 'c_TermId, c_DocId, c_Count, c_Top', $l_IstVals);		

	//	\hpnWse\stHttpSvc::cLog(stObjUtil::cEcdJson($l_TermIds));

	//	$this->eDb_StoTknMapForDoc($a_DocId, $a_Len, $a_TknMap);
	}

	private function eCalcUseTime()
	{
		$this->c_UseTime = round((microtime(true) - $this->e_SttTime) * 1e6) / 1000;
	}
}


} // namespace hpnWse\nSe

//////////////////////////////////// OVER ////////////////////////////////////